优化 Dockerfile 体积及构建速度
•
# 优化 Dockerfile 体积及构建速度
以 Nuxt 的基础项目 `npx nuxi init nuxt-app`,来介绍如何修改 Dockerfile 优化镜像体积及构建速度。
示例 demo 可以在 https://github.com/lbb00/docker-optimize-demo 查看。
效果:

## Dockerfile.0 一个基础的 Nuxt Dockerfile
```dockerfile
FROM node:16
EXPOSE 3000
WORKDIR /app
COPY . .
RUN yarn install --frozen-lockfile
RUN yarn build
ENTRYPOINT ["node"]
CMD [".output/server/index.mjs"]
```
在这个 Dockerfile 中,仅有最基本的安装依赖、构建。当执行完`docker build -f Dockerfile.0 . -t demo:v0`后,发现这个简单的 Image 足足有 1.52GB 的大小。
通过`docker history demo:v0`获取到 Image 的构建历史,获得到下图:

分析上图可知在 COMMENT 来自于 Dockerfile.0 之前就已经非常大了,这些内容来自于`FROM node:16`。
通过`docker pull node:16 && docker images node:16`,可以看到 node:16 这个基础镜像体积有 850MB。

## Dockerfile.1 使用体积更小更精简的基础镜像
在 Dockerfile.0 中发现 node:16 的基础镜像体积非常大,可以使用更小的 Node 镜像来代替。
- `node:<version>` 基于 Debian 的官方镜像
- `node:<version>-slim` 删除冗余依赖后的精简版本镜像,同样是基于 debian 构建,体积上比默认镜像小很多,删除了很多公共的软件包,只保留了最基本的 node 运行环境
- `node:<version>-alpine` 基于 Alpine 镜像构建
对比`docker pull node:16 && docker pull node:16-buster-slim && docker pull node:16-alpine && docker images | grep node`,node:16-alpine 体积是最小的。

Dockerfile.1 中把 `FROM node:16` 修改为 `FROM node:16-alpine` 后构建,镜像体积下降到 773MB。
> alpine 不是最佳选择,这里只是举例,建议使用 `node:<version>-slim` 或 `gcr.io/distroless/nodejs`
## Dockerfile.2 多阶段构建
对 Dockerfile.1 构建出的镜像执行`docker history demo:v1` ,得到下图:

现在占用体积比较大的还有 `RUN yarn install --frozen-lockfile` 这一层,node_modules 占用了不少的体积。大部分的 Node 开发场景中,项目在执行完 build 以后,仅需要一些生产时才需要用到的依赖,甚至通过一些打包工具后完全不需要任何来自 node_modules 的依赖。
在 build 完成以后,删除 node_modules,仅安装生产时所需要的依赖。但是由于 Image 中仍然包含了安装全部依赖的层,可以通过多阶段构建避免将没有必要的层写入到 Image 中。
在 Dockerfile.2 中,修改为:
```dockerfile
FROM node:16-alpine as builder
WORKDIR /app
COPY . .
RUN yarn install --frozen-lockfile
RUN yarn build
RUN rm -rf ./node_modules && yarn install --frozen-lockfile --production --ignore-scripts
FROM node:16-alpine
EXPOSE 80
WORKDIR /app
COPY --from=builder /app .
ENTRYPOINT ["node"]
CMD [".output/server/index.mjs"]
```
使用 Dockerfile.2 构建后,镜像体积仅有 112MB
## Dockerfile.3 利用 Docker layer cache 加速构建
关于 Docker 中层和缓存的概念在这里不在过多阐述。
在上述的 Dockerfile 中,如果修改了项目中的任何文件,甚至是 README.md,都会导致 Docker 从头开始构建,不能有效的利用缓存。特别是在 Node 项目中,项目经过一段时间迭代以后,依赖会越来越多,安装依赖也会成为构建过程中非常耗时的阶段之一。
因此在编写 Dockerfile 的时候可以遵循以下几点:
- 将不会变动或极少变动的内容尽可能放到顶部
- 仅 COPY 当前阶段所需要的文件
以本项目举例
- yarn install 的过程仅需要 package.json、yarn.lock
- build 时仅需要 tsconfig.json、app.vue、nuxt.config.ts
- 生成最终镜像的阶段也仅需要从 builder 那里拷贝 node_modules 和 .output 两个文件夹
在 Dockerfile.3 中,修改为:
```dockerfile
FROM node:16-alpine as builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY tsconfig.json app.vue nuxt.config.ts ./
RUN yarn build
RUN rm -rf ./node_modules
RUN yarn install --frozen-lockfile --production --ignore-scripts
FROM node:16-alpine
EXPOSE 3000
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.output .
ENTRYPOINT ["node"]
CMD ["./server/index.mjs"]
```
这样,只有 package.json、yarn.lock 文件发生了改动,构建时才会重新执行安装依赖。同理,只有 tsconfig.json、app.vue、nuxt.config.ts 发生了改动,才会重新执行构建,并且不会导致重新安装依赖。
## 推荐工具
- [Dive](https://github.com/wagoodman/dive) 一种用于探索 docker image、层和发现缩小 Docker/OCI 图像大小的工具。
## 参考
[1] [10 best practices to containerize Node.js web applications with Docker](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker)
[2] [3 simple tricks for smaller Docker images](https://learnk8s.io/blog/smaller-docker-images)
[3] [Choosing the best Node.js Docker image](https://snyk.io/blog/choosing-the-best-node-js-docker-image)
[4] [Docker and Node.js Best Practices](https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals)